Redux Middleware: From Getting Started to Writing Your Own
A deep dive into understanding Redux middleware internals and writing custom middleware for async requests and error handling.
Redux Middleware
Redux provides a middleware mechanism similar to that of web servers. In web development, middleware processes individual requests; in Redux, middleware processes individual actions. This allows developers to perform various centralized operations on specific actions within middleware, such as logging, data fetching, and error handling.
How to Use It
Redux provides the applyMiddleware method. You can apply middleware as follows:
1 | import { |
How It Works
The compose Function
Here we have a remarkable function — compose. It is also the core of applyMiddleware, as it implements the Redux middleware mechanism. Let’s look at the source code of compose:
1 | /** |
As the comments explain, its purpose is to compose multiple functions from right to left into a single function. The rightmost function consumes the arguments of the new function, and the results are passed leftward as arguments to each successive function.
Executing compose(f1, f2, f3) yields (...args) => f1(f2(f3(...args))). The core operation is reduce — for detailed usage, see the documentation.
1 | First reduce iteration |
Previously, compose was not implemented with reduce but with reduceRight as composeRight. Compared to the new implementation, the old version is easier to understand:
New version Merge Request
The new approach uses lazy evaluation for better performance
1 | /** |
Let’s return to:
1 | const createStoreWithMiddleware = compose( |
The final value of createStoreWithMiddleware is:
1 | applyMiddleware(nextAndRequest,errorCatcher)(DevTools.instrument()(window.devToolsExtension()(createStore))) |
The applyMiddleware Function
Here is its source code:
1 | import compose from './compose' |
The structure of a Redux middleware:
1 | store => next => action => { |
Suppose we have three middlewares M1, M2, M3. Calling applyMiddleware(M1, M2, M3) returns a closure that accepts createStore as its argument, allowing the store creation step to happen inside this closure.
The store is then reassembled into middlewareAPI as the new store — this is the store parameter in the outermost function of our middleware. This way, middleware can perform various operations based on the state tree.
Notice that the reassembled store only has two methods: getState for reading state, and dispatch for dispatching actions. Methods like setState, subscribe, and replaceReducer are not exposed. setState could trigger new actions during re-rendering, potentially causing infinite loops; subscribe is meant for subscribing to each dispatch operation, but you already have dispatch in your hands (next), so there’s no need to subscribe; replaceReducer is for dynamically loading new reducers — you probably won’t need it.
Each function in the middleware array is called with middlewareAPI as its argument, producing the chain array. At this point, each function in the chain array looks like this:
1 | next => action => { |
Core Code Explained
dispatch = compose(…chain)(store.dispatch)
Assuming chain contains three functions C1, C2, C3, then compose(...chain)(store.dispatch) is C1(C2(C3(store.dispatch))). From this we can deduce:
- The
nextin the last middleware M3 passed toapplyMiddlewareis the originalstore.dispatch; - The
nextin M2 isC3(store.dispatch); - The
nextin M1 isC2(C3(store.dispatch));
Finally, C1(C2(C3(store.dispatch))) is assigned as the new dispatch on the store and returned to the user. This is the dispatch method users actually call. Since C3, C2, and C1 have all been executed in sequence, each middleware has been reduced to:
1 | action => { |
Complete Flow When an Action Is Triggered
With this dispatch method and the unwrapped middleware, let’s trace the complete flow when a user triggers an action:
- Manually dispatch an action:
store.dispatch(action); - This calls
C1(C2(C3(store.dispatch)))(action); - C1’s code executes until it hits
next(action), wherenextis M1’snext, i.e.,C2(C3(store.dispatch)); C2(C3(store.dispatch))(action)executes until it hitsnext(action), wherenextis M2’snext, i.e.,C3(store.dispatch);C3(store.dispatch)(action)executes until it hitsnext(action), wherenextis M3’snext, i.e.,store.dispatch;store.dispatch(action)executes — internally the root reducer updates the current state;- Code after
next(action)in C3 executes; - Code after
next(action)in C2 executes; - Code after
next(action)in C1 executes;
That is: C1 -> C2 -> C3 -> store.dispatch -> C3 -> C2 -> C1
The onion model in action!
How to Write Middleware
After all that theory, let’s finally get to the main event — writing middleware. The goal is to implement middleware that handles async requests and error handling, so we don’t need to manually make async requests or handle errors after every request.
Let’s start with the simpler error handling middleware.
Error Handling Middleware
This middleware checks whether the action has an error field to determine whether to throw an error:
1 | import { notification } from 'antd' |
When an error field is found on the action, an error is thrown. This field can be set by upstream middleware when something goes wrong, attaching the error message to action.error for this middleware to handle. Since the project is based on antd, all errors are displayed using the notification component as a pop-up in the top-right corner.
To make this a generic error handler, you can wrap it in another function that accepts a custom error handler:
1 | export default handler => store => next => action => { |
Then the usage becomes:
1 | const createStoreWithMiddleware = compose( |
Async Request Middleware
Version 1
This middleware checks whether the action has a url field to determine if an async request should be made, and attaches the response to the action’s result field for the next middleware or reducer to use.
1 | import request from './request' |
Version 2
In this project, most scenarios require executing one async action followed by another to refresh the current list.
For example, after deleting or adding a record, we want to refresh the current list.
So we add a nextAction field to the action, enabling execution of a follow-up action after the current one:
1 | import request from './request' |
For flexibility, nextAction can also be a function that must return an action. The return value of the current action is passed as a callback parameter to this function. After nextAction executes, in addition to attaching the response as result, a lastResult field is also added to preserve the first action’s return value.
Version 3
Currently only one level of nextAction is supported. To support multiple levels, we can pass an array where each element can be either a plain action or a function that returns an action. Here’s the complete code:
1 | // index.js |
1 | // is-array.js |
1 | // is-function.js |
1 | // request.js |
1 | // exec-action.js |
1 | // exec-next-action.js |
And with that, we can happily build our pages!
Alright, time to send this to my mom for review.
Redux Middleware: From Getting Started to Writing Your Own

